[译]利用 WASM SQLite 给notion加速

Published on
Reading time
17 min read
Authors
notion

三年前,我们成功加快了Mac和Windows版Notion应用程序的速度,方法是使用SQLite数据库在客户端缓存数据。 我们还在本机移动应用程序中使用此SQLite缓存。

今年,我们为通过网页浏览器访问Notion的用户提供了同样的改进。本文深入探讨了如何使用WebAssembly(WASM)实现sqlite3来提高Notion在浏览器中的性能。

在所有现代浏览器中,使用 SQLite 可以将页面导航时间缩短 20%。对于因互联网连接等外部因素导致 API 响应时间特别慢的用户,这种差异更加明显。例如,澳大利亚用户的页面导航时间缩短了 28%,中国用户缩短了 31%,印度用户缩短了 33%。

WASM SQLite将页面跳转时间缩短了20%。

让我们来看看如何在浏览器中设置SQLite!

核心技术OPFS和Web Workers

为了在会话之间保存数据,WASM SQLite库使用了Origin专用文件系统(OPFS),这是一种现代浏览器API,允许网站读取和写入用户设备上的文件。

WASM SQLite库只能在Web Workers的持久层中使用OPFS。Web Worker可以被看作是在浏览器中与执行大多数JavaScript的主线程分开的线程中运行的代码。Notion与Webpack捆绑在一起,幸运的是Webpack提供了一个易于使用的语法来加载Web Worker。我们设置Web Worker,使其使用OPFS创建一个SQLite数据库文件或加载一个现有文件。然后,我们在该Web Worker上运行现有的缓存代码。我们使用优秀的Comlink库轻松管理主线程与Worker之间传递的消息。

我们基于SharedWorker的实现

我们的最终架构基于Roy Hashimoto在GitHub讨论中提出的新颖解决方案。Hashimoto描述了一种方法,即每次只有一个选项卡访问SQLite,同时仍允许其他选项卡执行SQLite查询。

这种新架构是如何工作的?简而言之,每个标签页都有自己的专用Web Worker,可以写入SQLite。然而,只有一个标签页可以实际使用它的Web Worker。SharedWorker负责管理哪个是 active tab。当活动标签页关闭时,SharedWorker会知道选择一个新的活动标签页。为了检测已关闭的选项卡,我们在每个选项卡上打开一个无限打开的Web Lock,如果Web Lock关闭,则说明选项卡已关闭。

我们基于 SharedWorker 的 WASM SQLite 实现架构。

为了执行任何SQLite查询,每个选项卡的主线程将查询发送给SharedWorker,后者将查询重定向到活动选项卡的专用Worker。任意数量的选项卡可以同时进行任意次数的SQLite查询,并且查询将始终发送到单个活动选项卡。

每个Web Worker都使用OPFS SyncAccessHandle Pool VFS实现来访问SQLite数据库,该实现适用于所有主流浏览器。

在接下来的章节中,我们将解释为什么需要以这种方式构建,以及尝试不同方法时遇到的障碍。

为什么一个更简单的方法行不通

在构建上述架构之前,我们尝试以更直接的方式运行WASM SQLite——每个标签页都有一个专用的Web Worker,每个Web Worker都向SQLite数据库写入数据。

我们有两个可选的WASM SQLite实现方案:

最终我们发现,如果直接使用,这两种方法都不足以满足我们的需求。

障碍1 跨源隔离

通过 sqlite3_vfs 实现的 OPFS 要求您的网站跨域隔离(cross-origin isolated)。在页面中添加跨源隔离需要设置一些安全标头,以限制可加载的脚本。要了解更多信息,请参阅“COOP 和 COEP 详解”

设置这些标头是一项艰巨的任务。在跨域隔离的情况下,仅在页面上设置这两个标头是不够的。应用程序加载的所有跨域资源都必须设置不同的标头,所有跨域 iframe 都必须附加一个附加属性,以允许它们在跨域隔离的环境中工作。在Notion,我们依赖许多第三方脚本来支持我们网络基础设施的各种功能,而实现完全的跨域隔离需要要求每个供应商设置新的标头并改变他们的iframe的工作方式,这是不切实际的。

在我们的测试中,我们能够通过使用Chrome和Edge浏览器上提供的Origin Trials for SharedArrayBuffer将此变体发送给一部分用户,从而获得关键的性能数据。这些原产地试验使我们能够暂时绕过跨原产地隔离的要求。

使用这种变通方法意味着我们只能在Chrome和Edge中启用此功能,而不能在其他常用浏览器(如Safari)中启用。但这些浏览器中的Notion流量足以收集一些性能数据。

Origin Trial 可为域中的 Chrome 用户启用 SharedArrayBuffer(WASM SQLite 的先决条件),而无需启用跨源隔离。

障碍2 腐败问题

当我们通过 sqlite3_vfs 为一小部分用户开启 OPFS 时,我们发现其中一些用户遇到了严重的问题。这些用户会在页面上看到错误的数据,例如,将评论归因于错误的同事,或者指向新页面的链接,而预览却完全是另一个页面。

显然,我们不能在这种状态下向100%的流量推出这项功能。查看受此错误影响的用户数据库文件时,我们注意到一个模式:他们的SQLite数据库以某种方式损坏。在某些表中选择行会引发错误,当我们检查这些行时,我们发现数据一致性问题,例如多个行具有相同的ID但内容不同。

这显然是数据错误的原因。但SQLite数据库是如何陷入这种状态的?我们推测问题是由并发问题引起的。可能同时打开了多个标签页,每个标签页都有一个专用的Web Worker,与SQLite数据库保持活动连接。Notion应用程序会频繁写入缓存——每次从服务器获取更新时都会这样做,这意味着标签页会同时写入同一个文件。尽管我们已经在使用事务处理方法将SQLite查询合并在一起,但我们强烈怀疑损坏是由于OPFS API的并发处理不当造成的。在SQLite论坛上进行的几次讨论似乎证实了其他人也在为OPFS如何管理并发性而苦恼(也就是说,他们对此知之甚少)。

因此,我们开始记录损坏错误,然后尝试了一些临时性的方法,例如添加 Web Locks,并只让焦点选项卡写入 SQLite。这些调整降低了损坏率,但不足以让我们有信心再次将此功能用于生产环境。不过,我们已经确认了是并发场景下导致的损坏。

Notion桌面应用没有这个问题。在该平台上,只有一个父进程会向SQLite写入数据;你可以随意打开多个标签页,但只有一个线程会访问数据库文件。我们的移动原生应用一次只能打开一个页面,但即使它有多个标签页,在这方面它也有与桌面应用类似的架构。

障碍3:替代方案一次只能在一个标签页中运行

我们还评估了OPFS SyncAccessHandle Pool VFS变体。该变体不需要SharedArrayBuffer,这意味着它可以在Safari、Firefox和其他没有Origin Trial for SharedArrayBuffer的浏览器中使用。

这种变体的缺点是每次只能在一个标签页中运行;如果试图在后续标签页中打开SQLite数据库,只会引发错误。

一方面,这意味着OPFS SyncAccessHandle Pool VFS没有OPFS via sqlite3_vfs变体的并发问题。当我们向一小部分用户启用该功能时,没有发现任何损坏问题,从而证实了这一点。另一方面,我们也不能立即启用该变体,因为我们希望所有用户的标签页都能受益于缓存。

结论

这两种变体都无法直接使用,这促使我们构建了上述SharedWorker架构,该架构与这两种SQLite变体中的任何一种都兼容。当通过sqlite3_vfs变体使用OPFS时,我们避免了损坏问题,因为一次只有一个选项卡写入。当使用OPFS SyncAccessHandle Pool VFS变体时,由于有SharedWorker,所有选项卡都可以缓存。

在确认该架构适用于这两种变体、性能提升明显且没有损坏问题后,我们最终决定采用哪种变体。我们选择了OPFS SyncAccessHandle Pool VFS,因为它不需要跨源隔离,否则我们无法在Chrome和Edge以外的任何浏览器上推出。

回归缓解问题

当我们开始向用户发布这一改进时,我们注意到了一些必须修复的缺陷,包括加载速度变慢。

页面加载速度较慢

我们的第一个观察结果是,虽然从一个Notion页面导航到另一个页面速度更快,但初始页面加载速度却更慢。经过一些分析,我们意识到页面加载通常不是数据获取的瓶颈——我们的应用程序启动代码在等待API调用完成时执行其他操作(解析JS、设置应用程序等),因此无法像导航一样从SQLite缓存中受益。

为什么速度会变慢?因为用户必须下载并处理WASM SQLite库,这会阻碍页面加载过程,导致其他页面加载操作无法同时进行。由于这个库只有几百KB,因此额外的时间在我们的指标中非常明显。

为了解决这个问题,我们对加载库的方式进行了细微调整—— 完全异步加载WASM SQLite ,确保它不会阻塞页面加载。这意味着初始页面数据很少会从SQLite加载。这很好,因为我们 客观地确定,从SQLite加载初始页面的加速效果并不足以抵消下载库的减慢效果

在做出这一改变后,实验组的初始页面加载指标与对照组的指标完全相同。

速度慢的设备无法从缓存中受益

我们在指标中注意到的另一个现象是,虽然从一个Notion页面导航到另一个页面的中值时间更快,但时间等到第95%的时候却更慢。某些设备,例如将浏览器指向Notion的手机,无法从缓存中受益,甚至变得更糟。

我们在移动团队之前进行的一项调查中找到了这个谜题的答案。当他们在我们的原生移动应用程序中实施缓存时,一些设备(例如较旧的安卓手机)从磁盘读取的速度非常慢。因此,我们不能假设从磁盘缓存加载数据会比从API加载相同数据更快。

通过这次移动调查,我们的页面加载已经具备了一定的逻辑,可以“比较”两个异步请求(SQLite和API)的速度。我们只需在导航点击的代码路径中重新实现这一逻辑即可。这样,两个实验组之间的导航时间就达到了95%。

最终结论

在浏览器中为Notion提供SQLite的性能改进面临诸多挑战。我们面临一系列未知因素,特别是新技术方面,并在此过程中吸取了一些教训:

  • OPFS 无法直接优雅地处理并发问题。开发人员应该意识到这一点,并围绕它进行设计。
  • Web Workers和SharedWorkers(以及本文未提及的Service Workers)具有不同的功能,必要时将它们组合起来可能会很有用。
  • 截至 2024 年春季,要在复杂的 Web 应用程序上完全实现跨源隔离并不容易,尤其是当您使用第三方脚本时。

借助浏览器中的 SQLite 为用户缓存数据,我们看到了导航时间缩短 20% 的效果,并且没有发现其他指标出现倒退。重要的是,我们没有发现任何因 SQLite 损坏而引发的问题。我们之所以能够取得成功并确保最终方案稳定,要归功于 SQLite 官方 WASM 实现背后的团队以及 Roy Hashimoto 及其向公众提供的实验性方法。


原文地址:

Notion engineers sped up Notion's browser speed with WASM SQLite
Notion engineers sped up Notion's browser speed with WASM SQLite
domain faviconwww.notion.so